Le projet se déroule du vendredi 20 auu 30 lundi novembre 2020.
Librairies
import requests
import re
from bs4 import BeautifulSoup
import datetime
import parsedatetime as pdt
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import plotly.graph_objects as go
import plotly.offline as pyo
from plotly.offline import plot
import plotly.express as px
from plotly.subplots import make_subplots
import plotly.tools as tls
from plotly.offline import download_plotlyjs, plot,iplot
from ipyleaflet import Polyline
from ipyleaflet import Marker, LayerGroup
from ipyleaflet import basemaps, basemap_to_tiles
from ipyleaflet import Map, AntPath
import warnings
warnings.filterwarnings('ignore')
import unidecode
from LatLon23 import *
from sklearn.linear_model import LinearRegression
from statsmodels.tsa.arima_model import ARIMA
sns.set_theme()
pyo.init_notebook_mode()
Conda environment
conda env export --name Vendee_Globe_env > environment.yml
Note: you may need to restart the kernel to use updated packages.
Plusieurs types de données sont accesibles en ligne :
def get_info_boat_from_url(url):
''' Renvoie les informations techniques des bateaux ainsi que le nom des participants
dans un dictionnaire {Nom du bateau : {Skipper, {infos techniques}}'''
r = requests.get(url)
soup = BeautifulSoup(r.content, 'html.parser')
info = {
div_tag.find('h3', attrs={'class': 'boats-list__popup-title'}).text : {
'Skipper' : div_tag.find('a').attrs['href'].split('/')[-1],
'Boat info' : {
li_tag.text.split(':')[0].strip() : li_tag.text.split(':')[1].strip()
for li_tag in div_tag.findAll('li')}
}
for div_tag in soup.findAll('div', attrs={'class': 'boats-list__popup-infos'})
}
return info
On crée un DataFrame de données sur la base du dictionnaire scrapé depuis le site du Vendée Globe. Ce dataframe sera preprocessé dans un second temps, afin d'étudier plus finement chaque variable extraite.
# Scraping des données du bateau et des Skippers
boat_info = get_info_boat_from_url('https://www.vendeeglobe.org/fr/glossaire')
df_source = pd.DataFrame.from_dict(boat_info, orient='index')
# Creation du DataFrame des données
boat_info_df = pd.DataFrame()
for k, v in boat_info.items():
temp_df = pd.DataFrame(v).T
temp_df['Boat'] = k
boat_info_df = pd.concat([boat_info_df, temp_df], axis=0)
boat_info_df = boat_info_df.reset_index()
boat_info_df = boat_info_df[boat_info_df['index'] != 'Skipper']
del boat_info_df['index']
boat_info_df = pd.merge(boat_info_df, df_source.reset_index(), left_on='Boat', right_on='index')
del boat_info_df['index']
del boat_info_df['Boat info']
boat_info = boat_info_df.set_index('Boat')
boat_info.head(2)
| Anciens noms du bateau | Architecte | Chantier | Date de lancement | Déplacement (poids) | Hauteur mât | Largeur | Longueur | Nombre de dérives | Numéro de voile | Surface de voiles au portant | Surface de voiles au près | Tirant d'eau | Voile quille | Skipper | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Boat | |||||||||||||||
| NEWREST - ART & FENÊTRES | No Way Back, Vento di Sardegna | VPLP/Verdier | Persico Marine | 01 Août 2015 | 7 t | 29 m | 5,85 m | 18,28 m | foils | FRA 56 | 570 m2 | 320 m2 | 4,50 m | monotype | fabrice-amedeo |
| PURE - Best Western® | Gitana Eighty, Synerciel, Newrest-Matmut | Bruce Farr Design | Southern Ocean Marine (Nouvelle Zélande) | 08 Mars 2007 | 9t | 28m | 5,80m | 18,28m | 2 | FRA 49 | 560 m2 | 280 m2 | 4,50m | acier forgé | romain-attanasio |
Pickle du DataFrame
boat_info.to_pickle("dataframes/boat_info.pkl")
Dernier classement disponible
Dans un premier temps, je récupère les informations relatives au dernier classement disponible. Ces informations sont scrappées directement depuis la page web du site du Vendée Globe. Comme pour les informations techniques des bateaux, le preprocessing des données se fait dans une deuxième temps.
def get_classement_from_url(url):
''' Renvoie les informations du classement de la course à une date donnée (la page url indique la date)
dans un dictionnaire '''
r = requests.get(url)
soup = BeautifulSoup(r.content, 'html.parser')
classement = {
div_tag.find('li', attrs={'class': 'rankings__number'}).text.strip() : {
'Skipper' : div_tag.find('p', attrs={'class': 'rankings__desc'}).text.strip().split(' ')[0],
'Bateau' : div_tag.find('p', attrs={'class': 'rankings__desc'}).text.strip().split(' ')[-1],
'Foil' : [1 if str(div_tag.find('svg', attrs={'class': 'icon-valid'})) != 'None' else 0][0],
'Race data' : {
second_list.text.strip().split('\n')[0] : second_list.text.strip().split('\n')[1:]
for second_list in div_tag.find('ul', attrs={'class':'rankings__list m--second l-unlist'})\
#.findAll('span', attrs={'class':'rankings__minititle desktop-hidden'})
.findAll('li')
}
}
for div_tag in soup.findAll('div', attrs={'class': 'rankings__item'})
}
return classement
def get_race_data_from_skipper(dict_data):
''' Retourne un dataframe des données de la course pour un dictionnaire donné '''
df1 = pd.DataFrame.from_dict(dict_data.get('Vitesse')).T
df1.columns = ['Vitesse nds', 'Vitesse kmh']
df2 = pd.DataFrame.from_dict(dict_data.get('VMG')).T
df2.columns = ['VMG']
if dict_data.get('Lat/Long') != []:
df3 = pd.DataFrame.from_dict(dict_data.get('Lat/Long')).T
df3.columns = ['Lat', 'Long']
# Pour les skippers hors course, affecte les valeurs de latitude et longitude du départ
else:
df3 = pd.DataFrame([["46° 30'8'' N", "1° 47'24'' O"]], columns=['Lat', 'Long'])
df4 = pd.DataFrame.from_dict(dict_data.get('Dist. au premier')).T
df4.columns = ['Dist au premier nm', 'Dist au premier km']
df5 = pd.DataFrame.from_dict(dict_data.get('Dist. à l\'arrivée ')).T
df5 = pd.concat([df5.iloc[:, :2], df5.iloc[:, -1:]], axis=1)
df5.columns = ['Dist a arrivee nm', 'Dist a arrivee km', 'percent classement']
race_data_skipper_df = pd.concat([df1, df2, df3, df4, df5], axis=1)
return race_data_skipper_df
def create_df_classement(url):
''' Crée le dataframe des données du classement à une date donnée '''
# Scrapping des données
classement = get_classement_from_url(url)
race_data_df = pd.DataFrame()
for k, v in classement.items():
temp_df = pd.DataFrame(get_race_data_from_skipper(v.get('Race data')))
temp_df['Num classement'] = k
temp_df['Skipper'] = v.get('Skipper')
race_data_df = pd.concat([race_data_df, temp_df], axis=0)
race_data_df = race_data_df.reset_index().set_index('Num classement')
del race_data_df['index']
classement_df = pd.DataFrame.from_dict(classement).T
classement_df = classement_df.reset_index().rename(columns={'index':'Position'})
classement_df = pd.merge(classement_df, race_data_df, left_on='Skipper', right_on='Skipper')
del classement_df['Race data']
return classement_df
# TOP 3 du dernier classement publié sur le site du Vendée Globe
classement_df = create_df_classement('https://www.vendeeglobe.org/fr/classement')
classement_df.head(3)
| Position | Skipper | Bateau | Foil | Vitesse nds | Vitesse kmh | VMG | Lat | Long | Dist au premier nm | Dist au premier km | Dist a arrivee nm | Dist a arrivee km | percent classement | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 1 | Charlie DALIN | APIVIA | 1 | 18.54 nds | 34.34 km/h | 16.44 nds | 40° 6'44'' S | 15° 33'33'' E | 0 nm | 0 km | 17695.5 nm | 32772.07 km | 27.2% |
| 1 | 2 | Thomas RUYANT | LinkedOut | 1 | 16.76 nds | 31.04 km/h | 16.75 nds | 40° 51'11'' S | 9° 47'21'' E | 241.12 nm | 446.55 km | 17936.6 nm | 33218.58 km | 26.2% |
| 2 | 3 | Kevin ESCOFFIER | PRB | 1 | 16.91 nds | 31.32 km/h | 16.64 nds | 40° 54'37'' S | 9° 10'32'' E | 266.99 nm | 494.47 km | 17962.4 nm | 33266.36 km | 26.1% |
Historique de tous les classements
Pour effectuer des analyses sur la totalité de la course, je récupère de manière automatisée tous les classements historiques publiés depuis le 1er jour, c'est à dire 6 classements par jour depuis le 8 Novembre 2020.
Cette méthode permet de ne pas avoir à télécharger chaque nouveau fichier excel publié sur le site du Vendée Globe.
Comme pour le DataFrame des informations techniques des bateaux, ces données seront préprocessées dans un second temps.
def get_dates_rank_from_url(url):
''' Renvoie les dates des classements précédents de la course (i.e les classements qui ont été publiés
depuis le début de la course) '''
r = requests.get(url)
soup = BeautifulSoup(r.content, 'html.parser')
dates_classement = [
select_tag.text[2:]
for select_tag in soup.findAll('option') if select_tag.text[0] == '-'
]
return dates_classement
def create_date_url(date_liste):
''' Retourne une liste de dates au format de l'url du site du Vendée Globe, à partir de la liste
des timestamp des classements historiques '''
return [datetime.datetime.strptime(date, '%Y-%m-%d %H:%M:%S').strftime('%Y%m%d_%H%M%S') \
for date in date_liste]
%%time
# Dates des historiques de classement publiés sur le site du Vendée Globe
dates_classement = get_dates_rank_from_url('https://www.vendeeglobe.org/fr/classement')
# Scraping des informations de tous les classements historiques publiés sur le site du Vendée Globe
classement_df = pd.DataFrame()
liste_dates_url = create_date_url(dates_classement)
for idx, date in enumerate(liste_dates_url):
#print('Date : ', dates_classement[idx])
temp_classement = create_df_classement('https://www.vendeeglobe.org/fr/classement/' + date)
temp_classement['Date'] = dates_classement[idx]
classement_df = pd.concat(
[classement_df,
temp_classement],
axis=0)
#classement_df
CPU times: user 1min 32s, sys: 1.67 s, total: 1min 34s Wall time: 2min 3s
Pickle du DataFrame
classement_df.to_pickle("dataframes/Classements_hist.pkl")
Les données techniques des bateaux et les données du classement de la course ont été importées de manière automatisée (si le site se met à jour, on exécute la fonction de scrapping pour avoir les données les plus fraîches, sans passer par un fichier excel). Avant d'analyser les données obtenues, assurons nous de la cohérence des formats, qualité de données, etc.
Load DataFrames
boat_info = pd.read_pickle("dataframes/boat_info.pkl")
Les données des participants et des bateaux sont des données statiques, i.e elles n'évolueront pas en fonction de la progression de la course, ni en fonction du classement. On peut donc traiter les données une fois pour toutes.
boat_info_preproc = boat_info.copy()
Une vision générale de nos données nous indique que :
boat_info_preproc.info()
<class 'pandas.core.frame.DataFrame'> Index: 33 entries, NEWREST - ART & FENÊTRES to CORUM L'EPARGNE Data columns (total 15 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 Anciens noms du bateau 24 non-null object 1 Architecte 33 non-null object 2 Chantier 33 non-null object 3 Date de lancement 33 non-null object 4 Déplacement (poids) 33 non-null object 5 Hauteur mât 33 non-null object 6 Largeur 33 non-null object 7 Longueur 33 non-null object 8 Nombre de dérives 33 non-null object 9 Numéro de voile 32 non-null object 10 Surface de voiles au portant 33 non-null object 11 Surface de voiles au près 33 non-null object 12 Tirant d'eau 33 non-null object 13 Voile quille 32 non-null object 14 Skipper 33 non-null object dtypes: object(15) memory usage: 4.1+ KB
Numéro de voile
Le champ "Numéro de voile" est une information importante puisqu'il donne le trigramme associé au pays. On remarque que cette colonne n'est pas complète dans le DataFrame des données. En effet, il manque parfois l'information du pays, et pour un Skipper (Thomas RUYANT), aucune donnée n'a été scrapée).
Pour compléter les informations manquantes, je me base sur un des fichiers excel de classements, dans lequel ce champ est complété correctement pour tous les bateaux. Il est toutefois nécessaire d'effectuer des traitements sur les noms de ce fichier (notamment retirer les accents), afin de ne pas biaiser les jointures.
J'harmonise également le nom de Samantha Davies, parfois orthographié 'Sam Davies'.
# Exemple pour Thomas RUYANT ou le numéro de voile est NaN
boat_info_preproc.loc['LinkedOut']
Anciens noms du bateau NaN Architecte Verdier Chantier Persico Date de lancement 03 Septembre 2019 Déplacement (poids) 8 t Hauteur mât 29 m Largeur 5,85 m Longueur 18,28 m Nombre de dérives foils Numéro de voile NaN Surface de voiles au portant 560 m2 Surface de voiles au près 350 m2 Tirant d'eau 4,50 m Voile quille acier forgé Skipper thomas-ruyant Name: LinkedOut, dtype: object
# Données issues d'un fichier excel publié sur le site du Vendée Globe (n'importe lequel)
def get_num_voiles():
num_voiles_df = pd.read_csv('data/Numero_voile.csv', encoding='utf-8', delimiter=';')
# unidecode permet de retirer les accents
num_voiles_df['Skipper name decoded'] = (num_voiles_df['Skipper'].apply(lambda x: \
unidecode.unidecode(x.split('\n')[0])))
num_voiles_df['Skipper name coded'] = (num_voiles_df['Skipper'].apply(lambda x: \
(x.split('\n')[0])))
num_voiles_df['Boat'] = num_voiles_df['Skipper'].apply(lambda x: \
unidecode.unidecode(x.split('\n')[1]))
del num_voiles_df['Skipper']
num_voiles_df['Numéro de voile'] = num_voiles_df['Num voile'].apply(lambda x: \
(x.replace('\n', ' ').split()[-1]))
num_voiles_df['Country'] = num_voiles_df['Num voile'].apply(lambda x: \
x.replace('\n', ' ').split()[-2])
del num_voiles_df['Num voile']
return num_voiles_df
# Informations issues du fichier excel de classement mises au bon format
get_num_voiles().head()
| Skipper name decoded | Skipper name coded | Boat | Numéro de voile | Country | |
|---|---|---|---|---|---|
| 0 | Thomas Ruyant | Thomas Ruyant | LinkedOut | 59 | FRA |
| 1 | Alex Thomson | Alex Thomson | HUGO BOSS | 99 | GBR |
| 2 | Charlie Dalin | Charlie Dalin | APIVIA | 79 | FRA |
| 3 | Jean Le Cam | Jean Le Cam | Yes we Cam ! | 01 | FRA |
| 4 | Kevin Escoffier | Kevin Escoffier | PRB | 85 | FRA |
def preproc_num_voile(df, df_num_voile):
''' Preprocessing du champ numéro de voile, pour obtenir 2 champs :
- Numéro de voile
- Country '''
df_res = df.copy()
# Format du champ nom du Skipper pour jointure
df_res['Skipper'] = df_res['Skipper'].apply(lambda x: ' '.join(x.split('-')).title())
# Completion d'un nom dans le DF des fiches techniques
mask = df_res['Skipper'] == 'Sam Davies'
df_res['Skipper'] = df_res['Skipper'].where(~mask, other='Samantha Davies')
# Rapprochement des donneés de numéro de voile et de pays
del df_res['Numéro de voile']
df_res = pd.merge(df_res.reset_index(),
df_num_voile[['Skipper name decoded', 'Skipper name coded', 'Numéro de voile', 'Country', 'Boat']],
how = 'left',
left_on='Skipper',
right_on='Skipper name decoded').rename(columns={'Boat_y':'Boat'}).set_index('Boat')
del df_res['Boat_x']
del df_res['Skipper']
return df_res
Tirant d'eau, longueur du bateau et hauteur de mât
Les champs "Tirant d'eau", "longueur du bateau" et "hauteur de mât" ne sont théoriquement pas discriminants, puisqu'ils sont définis de manière réglementaire et chaque courreur doit se conformer aux valeurs suivantes :
Néanmoins, la course du Vendée Globe applique la "Grand father rule" qui permet aux participants possédant un bateau construit avant la mise en place des règles ci-dessus, de concourrir sans changer de matériel.
Observons les données de l'édition 2020, afin de vérifier si pour ces éléments techniques, la "Grand father rule" s'applique. Si c'est le cas, on ne supprimera pas le champs de notre jeu de données, puisqu'il apportera de l'information.
print(boat_info_preproc['Tirant d\'eau'].value_counts())
print(boat_info_preproc['Longueur'].value_counts())
print(boat_info_preproc['Hauteur mât'].value_counts())
4,50 m 29 4,5 m 2 4,50m 2 Name: Tirant d'eau, dtype: int64 18,28 m 31 18,28m 2 Name: Longueur, dtype: int64 29 m 19 28 m 6 27 m 3 27,40 m 1 27,30 m 1 28m 1 28,50 1 26 m 1 Name: Hauteur mât, dtype: int64
Tous les bateaux possèdent le même tirant d'eau (4,50m) et la même longeur (18,28m). En revanche, certains bateaux ont une hauteur de mât plus petite que les 29mètres maximums réglementaires. Il est donc nécessaire de conserver ce champ qui est discriminant pour une partie des courreurs de cette édition.
def preproc_reglementaire(df):
''' Suppression des champs non discriminants, car réglementaires :
- Tirant d'eau
- Longeur '''
df_res = df.copy()
del df_res['Tirant d\'eau']
del df_res['Longueur']
return df_res
Surfaces de voile
Après vérification, toutes les données sont en $m^{2}$. On peut donc formatter ces colonnes pour obtenir un type Int.
def preproc_surface_voiles(df):
''' Preprocessing des champs de surface de voiles. Ces champs sont formattés pour
donner des colonnes d'integer '''
df_res = df.copy()
df_res['Surface de voiles au portant'] = df_res['Surface de voiles au portant'].apply(lambda x: int(x.split()[0]))
df_res['Surface de voiles au près'] = df_res['Surface de voiles au près'].apply(lambda x: int(x.split()[0]))
return df_res
Largeur, hauteur du mât
Les valeurs dont l'unité est le mètre peuvent être converties en Float dans notre jeu de données.
def parse_num_values(x):
''' Parser de valeurs numeriques '''
return float(re.findall('[0-9]+[,]?[0-9]+', x)[0].replace(',', '.'))
def preproc_meter_values(df):
''' Preprocessing des champs dont l'unité est le mètre '''
df_res = df.copy()
df_res['Largeur'] = df_res['Largeur'].apply(parse_num_values)
df_res['Hauteur mât'] = df_res['Hauteur mât'].apply(parse_num_values)
return df_res
Déplacement (poids) et Voile quille
Il y a deux bateaux pour lesquels le poids n'est pas renseigné sur le site du Vendée Globe. Afin de compléter ces valeurs, observons le poids moyen des bateaux dont la quille est construite dans le même matériau. Cela donnera en effet, une idée approchée des poids manquants.
boat_info_preproc[(boat_info_preproc['Déplacement (poids)'] == 'NC') | \
(boat_info_preproc['Déplacement (poids)'] == 'nc')]\
[['Skipper', 'Voile quille', 'Déplacement (poids)']]
| Skipper | Voile quille | Déplacement (poids) | |
|---|---|---|---|
| Boat | |||
| PRB | kevin-escoffier | Acier mécano soudé | NC |
| LA FABRIQUE | alan-roura | carbone | nc |
Le matériau de la quille du bateau CORUM L'EPARGNE n'étant pas renseigné, j'ai repris le matériau qui avait été communiqué en 2019 (https://www.corum.fr/actus/le-demoulage-de-la-coque-du-futur-bateau-corum-lepargne-en-images) : Carbone.
def preproc_voile_quille(df):
''' Preprocessing pour le champ Voile quille. Il s'agit d'uniformiser les labels similaires '''
df_res = df.copy()
df_res['Voile quille'] = df_res['Voile quille'].fillna('carbone')
df_res['Voile quille'] = df_res['Voile quille'].apply(lambda x: x.title())
return df_res
def preproc_deplacement_naive(df):
''' Preprocessing pour le champs "Déplacement", en tonnes '''
df_res = df.copy()
df_res = preproc_voile_quille(df_res)
df_res['Déplacement (poids)'] = df_res['Déplacement (poids)'].apply(lambda x: \
float('.'.join(re.findall('[0-9]', '.'.join(x.split())))) \
if (x not in ('NC', 'nc')) else -1)
return df_res
bar_chart_df = preproc_deplacement_naive(boat_info_preproc)
bar_chart_df = bar_chart_df[bar_chart_df['Déplacement (poids)'] != -1]\
[['Déplacement (poids)', 'Voile quille']]\
.groupby('Voile quille').mean()
bar_chart_df.plot(kind='barh',
title='Poids moyen du bateau en fonction du matériau de la quille');
Modifions la fonction de preprocessing pour prendre en compte les valeurs ci-dessus, et compléter les champs manquants dans nos données.
def preproc_deplacement(df):
''' Preprocessing pour le champs "Déplacement", en tonnes '''
df_res = df.copy()
df_res = preproc_voile_quille(df_res)
df_res['Déplacement (poids)'] = df_res['Déplacement (poids)'].apply(lambda x: \
float('.'.join(re.findall('[0-9]', '.'.join(x.split())))) \
if (x not in ('NC', 'nc')) else -1)
poids_moyen_df = df_res[df_res['Déplacement (poids)'] != -1]\
[['Déplacement (poids)', 'Voile quille']]\
.groupby('Voile quille').mean().rename(columns={'Déplacement (poids)':'Déplacement moyen'})
df_res_corr = pd.merge(df_res.reset_index(),
poids_moyen_df,
how = 'left',
left_on='Voile quille',
right_on='Voile quille').set_index(df_res.index.names)
mask = df_res_corr['Déplacement (poids)'] == -1.0
df_res_corr['Déplacement (poids)'].where(~mask, other=df_res_corr['Déplacement moyen'].where(mask))
del df_res_corr['Déplacement moyen']
return df_res
Date de lancement
Le champ Date est pour le moment converti en DateTime. Par la suite, on pourra par exemple se concentrer sur l'année de construction du bateau.
def preproc_date(df):
''' Preprocessing du champ date dans un format adapté '''
calendar = pdt.Calendar(pdt.Constants(localeID='fr', usePyICU=True))
df_res = df.copy()
df_res['Date de lancement'] = pd.to_datetime(df_res['Date de lancement'].apply(lambda x : \
(calendar.parseDT(x)[0].date())))
return df_res
Architecte
L'architecte du bateau peut jouer un rôle important dans la course et on peut supposer que les choix de ceux-ci en termes de matériaux utilisés et présence de certains éléments (foils notamment), pourraient faire la différence dans la course.
Dans les données scrappées, le libellé pour l'architecte n'est pas homogénéisé, on retrouve notamment plusieurs fois les noms VPLP et Verdier, associés différement (ou encore Bruce Farr design ortographié de plusieurs manières). Le nettoyage de ce champ va nécessiter quelques associations manuelles.
boat_info_preproc['Architecte'].value_counts()
Verdier - VPLP 7 Juan Kouyoumdjian 2 Bruce Farr design 2 VPLP - Verdier 2 Groupe Finot-Conq 2 Verdier 2 Bruce Farr Design 2 VPLP 2 Owen Clarke Design 2 Pierre Rolland 1 Owen Clarke 1 VPLP/Verdier 1 Samuel Manuard 1 Bruce Farr Yacht Design 1 Owen Clarke Design LLP - Clay Oliver 1 Marc Lombard 1 Lavanos 1 VPLP - Alex Thomson Racing (led by Pete Hobson) 1 Finot-Conq Design 1 Name: Architecte, dtype: int64
def preproc_architecte(df):
''' Preprocessing du champ Architecte, afin de regrouper sous un unique libellé, les
mêmes architectes'''
def clean_architecte(x):
split = re.findall('[A-Z]+', x.upper())
if split[0] == 'VPLP' or split[0] == 'VERDIER':
return ' '.join(sorted(split))
elif split[0] == 'BRUCE' and split[2] == 'YACHT':
split.remove('YACHT')
return ' '.join((split))
elif split[0] == 'OWEN' and split[-1] == 'DESIGN':
split.remove('DESIGN')
return ' '.join((split))
elif split[0] == 'FINOT' and split[-1] == 'DESIGN':
split.pop()
return ' '.join(['GROUPE'] + split)
else:
return ' '.join((split))
df_res = df.copy()
df_res['Architecte'] = df_res['Architecte'].apply(clean_architecte)
return df_res
architects_plot = preproc_architecte(boat_info_preproc)
architects_plot['Architecte'].value_counts().plot(kind='barh',
title='Nombre de bateaux designés par architecte/groupe d\'architectes');
Pipeline
Toutes les fonctions de preprocessing ont été définies ci-dessus. Nous pouvons maintenant les appliquer grâce à une fonction 'pipeline'. On s'assurera que le nombre d'entrées dans notre table est bien le même qu'initialement.
def pipeline_preprocessing_boats(df):
''' Pipeline de preprocessing pour le df des informations techniques des bateaux '''
num_voiles_df = get_num_voiles()
df = preproc_num_voile(df, num_voiles_df)
df = preproc_reglementaire(df)
df = preproc_surface_voiles(df)
df = preproc_meter_values(df)
df = preproc_deplacement(df)
df = preproc_date(df)
df = preproc_architecte(df)
return df
boat_info_preproc = boat_info.copy()
boat_info_clean = pipeline_preprocessing_boats(boat_info_preproc)
boat_info_clean.head(2)
| Anciens noms du bateau | Architecte | Chantier | Date de lancement | Déplacement (poids) | Hauteur mât | Largeur | Nombre de dérives | Surface de voiles au portant | Surface de voiles au près | Voile quille | Skipper name decoded | Skipper name coded | Numéro de voile | Country | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Boat | |||||||||||||||
| Newrest - Art et Fenetres | No Way Back, Vento di Sardegna | VERDIER VPLP | Persico Marine | 2015-08-01 | 7.0 | 29.0 | 5.85 | foils | 570 | 320 | Monotype | Fabrice Amedeo | Fabrice Amedeo | 56 | FRA |
| Pure - Best Western Hotels and Resorts | Gitana Eighty, Synerciel, Newrest-Matmut | BRUCE FARR DESIGN | Southern Ocean Marine (Nouvelle Zélande) | 2007-03-08 | 9.0 | 28.0 | 5.80 | 2 | 560 | 280 | Acier Forgé | Romain Attanasio | Romain Attanasio | 49 | FRA |
Pickle du dataframe clean
boat_info_clean.to_pickle("dataframes/boat_info_clean.pkl")
Comme pour les informations techniques des bateaux, il est nécessaire de preprocesser les données avant qu'elles soient utilisables pour les analyses.
classement_df = pd.read_pickle("dataframes/Classements_hist.pkl")
Latitude et Longitude
Les latitudes et longitudes de chaques participants au fil de la course peuvent être exploitées pour avoir un aperçu graphique de l'avancement des courreurs sur une carte du monde. Pour cela, il est nécessaires que les coordonnées soient dans un format exploitable par la fonction string2latlon.
def parse_latlon(x):
''' Parser pour latitudes et longitudes '''
res = (x.split('°')[0] + ' ' + x.split('°')[1].split('\'')[0] + \
' ' + x.split('°')[1].split('\'')[1] + ' ' + x[-1])\
.replace('O', 'W')\
.replace(' ', ' ')
return res
def preproc_latlon(df):
''' Preprocessing des latitudes et longitudes, vers un format exploitable pour l'affichage graphique '''
df_res = df.copy()
# Parsing des latitudes et longitudes
df_res['Latitude'] = df_res['Lat'].apply(parse_latlon)
df_res['Longitude'] = df_res['Long'].apply(parse_latlon)
df_res['LatLon'] = (df_res['Latitude'] + '-' + df_res['Longitude'])
df_res['LatLon'] = df_res['LatLon'].apply(lambda x: \
string2latlon(x.split('-')[0], x.split('-')[1], 'd% %m% %S% %H'))
df_res['Latitude'] = df_res['LatLon'].apply(lambda x: round(float(x.to_string()[0]), 4))
df_res['Longitude'] = df_res['LatLon'].apply(lambda x: round(float(x.to_string()[1]), 4))
del df_res['LatLon']
return df_res
Skipper
On formate la casse pour le champ 'Skipper' en vue des jointures à venir avec la table des informations techniques des bateaux. On note au passage que dans la table des classements, les noms des skippers sont avec accents. Cela ne devrait pas poser de problème puisque nous avons conservé deux versions des noms dans la table des infos techniques ; avec et sans accents. Cependant, après quelques tests, je réalise que certains noms ne sont pas accentués de la même manière. Je décide donc de retirer les accents des noms du tableau de classements.
De plus, un des skipper n'a pas de nom (uniquement un prénom) dans la table des classements : "Alan". En regardant les valeurs depuis les informations techniques, on remarque qu'il s'agit d'Alan Roura. On complète donc son nom.
Comme pour les données techniques des bateaux, le nom de Samantha Davies est harmonisé.
def preproc_skipper_name(df):
''' Preprocessing des nom des skippers : completion des noms et harmonisation des formats (accents) '''
df_res = df.copy()
df_res['Skipper'] = df_res['Skipper'].apply(lambda x: x.lower().title())
df_res['Skipper'] = df_res['Skipper'].apply(lambda x: unidecode.unidecode(x))
mask1 = df_res['Skipper'] == 'Alan'
df_res['Skipper'] = df_res['Skipper'].where(~mask1, other='Alan Roura')
mask2 = df_res['Skipper'] == 'Sam Davies'
df_res['Skipper'] = df_res['Skipper'].where(~mask2, other='Samantha Davies')
return df_res
Date
def preproc_date_classement(df):
''' Preprocessing du champ date '''
df_res = df.copy()
df_res['Date'] = pd.to_datetime(df_res['Date'])
return df_res
Champs numériques
La plupart des informations relatives aux classements sont des champs numériques, dont l'unité peut être le kilomètre/h, le kilomètre, le noeud marin, etc.
On utilise une fonction permettant de ne garder que la partie numérique (sans l'unité) de chaque champs, afin de transformer les données dans un format exploitable. Les champs concernés sont :
def preproc_numerical_values(df):
''' Preprocessing des champs numériques '''
def parse_num(x):
x_res = float(''.join(re.findall('[0-9]+[.]?[0-9]*', x)))
return x_res
df_res = df.copy()
df_res['percent classement'] = df_res['percent classement'].apply(parse_num)
df_res['Dist a arrivee km'] = df_res['Dist a arrivee km'].apply(parse_num)
df_res['Dist a arrivee nm'] = df_res['Dist a arrivee nm'].apply(parse_num)
df_res['Dist au premier km'] = df_res['Dist au premier km'].apply(parse_num)
df_res['Dist au premier nm'] = df_res['Dist au premier nm'].apply(parse_num)
df_res['VMG'] = df_res['VMG'].apply(parse_num)
df_res['Vitesse nds'] = df_res['Vitesse nds'].apply(parse_num)
df_res['Vitesse kmh'] = df_res['Vitesse kmh'].apply(parse_num)
return df_res
Foil
Pour faciliter les traitements à venir, on change le type de champ Foil en integer.
def preproc_foil(df):
''' Indique la présence ou l'absence d'un foil par un Integer (1 ou 0) '''
df_res = df.copy()
df_res['Foil'] = df_res['Foil'].apply(lambda x: int(x))
return df_res
Pipeline
Comme pour le preprocessing des fiches techniques des bateaux, on automatise le preprocessing des données de classement via un pipeline regroupant toutes les fonctions définies ci-dessus. De cette manière, l'ajout ou la suppression d'une tâche de preprocessing est facilitée.
def pipeline_preprocessing_classements(df):
''' Pipeline de preprocessing pour le df des classements de la course '''
df_res = df.copy()
df_res = preproc_latlon(df_res)
df_res = preproc_date_classement(df_res)
df_res = preproc_numerical_values(df_res)
df_res = preproc_skipper_name(df_res)
df_res = preproc_foil(df_res)
return df_res
classement_preproc = classement_df.copy()
classement_clean = pipeline_preprocessing_classements(classement_preproc)
classement_clean.head(2)
| Position | Skipper | Bateau | Foil | Vitesse nds | Vitesse kmh | VMG | Lat | Long | Dist au premier nm | Dist au premier km | Dist a arrivee nm | Dist a arrivee km | percent classement | Date | Latitude | Longitude | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 1 | Charlie Dalin | APIVIA | 1 | 18.54 | 34.34 | 16.44 | 40° 6'44'' S | 15° 33'33'' E | 0.00 | 0.00 | 17695.5 | 32772.07 | 27.2 | 2020-11-30 14:00:00 | -40.1122 | 15.5592 |
| 1 | 2 | Thomas Ruyant | LinkedOut | 1 | 16.76 | 31.04 | 16.75 | 40° 51'11'' S | 9° 47'21'' E | 241.12 | 446.55 | 17936.6 | 33218.58 | 26.2 | 2020-11-30 14:00:00 | -40.8531 | 9.7892 |
# Suppression des lignes pour les participants hors course
plot_df = classement_clean[(classement_clean['Position'] != 'RET') & (classement_clean['Position'] != 'NL')]
plot_data = plot_df.to_csv('data/plot.csv', index = True)
Pickle du dataframe clean
classement_clean.to_pickle("dataframes/classement_clean.pkl")
plot_df.to_pickle("dataframes/classements_hist_clean.pkl")
Nous avons importé les longitudes et latitudes, enregistrées 6 fois par jour depuis le début de la course, pour tous les participants. Affichons les trajectoires des bateaux sur une carte du monde, afin d'avoir un aperçu des routes de chacuns.
Le code est enregistré dans un fichier .py à part (repris en annexe).
Ci-dessous, un exemple d'affichage du résultat (30-11-2020).
J'ai choisi d'afficher quelques informations utiles sur chaque participant, lorsque l'utilisateur passe sa souris sur une trace.
En annexe, la trajectoire de Jérémie Beyou, qui après quelques jours de navigation a du retourner au point de départ suite à un problème technique (avant de repartir de plus belle dans la course voir graphique des distances cumulées parcourues par les skippers).

La légende est triée par position des participants dans la course. Celle-ci permet une sélection de certains participants en particulier, en cliquant sur les noms des skippers.
Ci-dessous j'ai selectionné quelques participants de la course, bien et moins bien classés (en date du 30-11-2020), afin d'avoir un aperçu de la différence des routes choisies par les participants.
Il est intéressant de voir la trajectoire prise par les navigateurs pour contourner l'anticyclone de St Hélène. En effet, ceux-ci ont tout intérêt à ne pas couper directement ver le Cap de Bonne Espérance, ce qui les ferait naviguer face au vent. Il s'agit de trouver la trajectoire idéale ; le contourner assez loin pour ne pas tomber dans les zones de calme au milieu de l'anticyclone, mais pas trop loin pour éviter d'augmenter considérablement la distance à parcourir.
Les skippers étant arrivés à des dates différentes autour de l'anti-cyclone, ils ont du adapter leur stratégie de navigation en fonction de l'évolution de celui-ci. Certains ont décidé de passer proche du coeur de l'anti-cyclone, ce qui généralement est une zone de vents faibles. D'autres, arrivés plus tard, ont choisit de contourner par l'Ouest, augmentant la distance à parcourir, mais pouvant potentiellement bénéficier d'un second anti-cyclone venant du Brésil, souvent avantageux en terme de vent.

Afin d'observer l'avancée de la course sous des angles différents, j'ai choisi de représenter les trajectoires des 3 premiers skippers dans le classement, ainsi que les 3 derniers (voir légende). Pour cela :
Ces cartes ne sont pas interactives comme le sont celles ci-dessus.
Le code permettant de générer ces cartes est également repris en annexe.
|
|
Nous avons extrait les données techniques relatives aux bateaux, ainsi que celles des classements des participants au fur et à mesure de la course. Nous avons également créé des pipelines de preprocessing afin d'automatiser les retraitements des données à chaque mise à jour des informations du site web du Vendée Globe.
Nous avons construit une carte des routes empruntées par chaque participant, afin d'avoir un aperçu de la progression de la course.
Nous pouvons désormais analyser plus finement les données et les corrélations qui peuvent exister entre les informations que nous avons retenues. Nous pouvons par exemple chercher si certaines caractéristiques techniques des bateaux offrent un avantage significatif aux participants.
Load des dataframes pickles
boat_info_clean = pd.read_pickle("dataframes/boat_info_clean.pkl")
classement_clean = pd.read_pickle("dataframes/classement_clean.pkl")
classements_hist_clean = pd.read_pickle("dataframes/classements_hist_clean.pkl")
def preproc_position(df):
df_res = df.copy()
df_res = df_res[(df_res['Position'] != 'NL') &
(df_res['Position'] != 'RET')]
df_res['Position'] = df_res['Position'].apply(lambda x: int(re.findall('[0-9]*', x)[0]))
return df_res
classements_hist_clean = preproc_position(classements_hist_clean)
Tableau des abandons
Au fur et à mesure que la course progresse, des problèmes techniques peuvent contraindrent les skippers à abandonner le Vendée Globle. Ci-dessous, les navigateurs n'étant plus dans la course, avec leur position au moment de l'abandon.
Note : Alex Thomson a abandonné la course, mais les informations sur le site du Vendée Globe ne sont pas encore à jour. Il apparaîtra dans le tableau dès que celles ci auront été modifiées.
def show_abandons(df):
''' Tableau des abandons de la course '''
abandons = df.copy()
abandons = abandons[abandons['Position'].apply(lambda x: (x not in [str(x) for x in range(34)]) and
x != 'NL')]\
[['Skipper', 'Bateau', 'Foil']].drop_duplicates()
min_date = df['Date'].unique().min()
distance_totale = df.loc[df['Date']==min_date]\
['Dist a arrivee km'].unique()[0]
info_abandons_tot = pd.DataFrame()
for skip in abandons['Skipper'].unique():
sub_abandon = df[(df['Skipper']==skip) & (df['Position'] != 'RET')]
max_date = sub_abandon['Date'].unique().max()
info_abandon = sub_abandon[sub_abandon['Date'] == max_date][['Position', 'Skipper', 'Dist a arrivee km',
'Date']]
info_abandons_tot = pd.concat([info_abandons_tot, info_abandon], axis=0)
abandons_filled = pd.merge(abandons, info_abandons_tot,
left_on = 'Skipper', right_on='Skipper', how='left')
abandons_filled['Distance parcourue (km)'] = abandons_filled['Dist a arrivee km'].apply(lambda x: distance_totale - x)
abandons_filled = abandons_filled.rename(columns={
'Dist a arrivee km': 'Distance à l\'arrivée (km)',
'Position': 'Position à l\'abandon'
}).set_index(['Skipper', 'Bateau', 'Position à l\'abandon'])
return abandons_filled
show_abandons(classement_clean)
| Foil | Distance à l'arrivée (km) | Date | Distance parcourue (km) | |||
|---|---|---|---|---|---|---|
| Skipper | Bateau | Position à l'abandon | ||||
| Nicolas Troussel | CORUM L'EPARGNE | 12 | 1 | 41452.2 | 2020-11-16 14:00:00 | 3545.1 |
Impacts des spécificités techniques sur les données moyennes du classement
Observons les impacts des spécificités techniques sur les données moyennes du classement pour chaque participant :
# Données moyennes sur la course
classement_moyen_df = classements_hist_clean[['Position', 'Skipper', 'Bateau', 'Vitesse kmh', 'VMG', 'Dist au premier km',
'Dist a arrivee km', 'Foil']]\
.groupby(['Skipper', 'Bateau']).mean(['Position', 'Vitesse kmh', 'VMG', 'Foil',
'Dist au premier km', 'Dist a arrivee km'])\
.rename(columns={'Position':'Position moy',
'Vitesse kmh':'Vitesse kmh moy',
'VMG':'VMG moy',
'Dist au premier km':'Dist au premier km moy',
'Dist a arrivee km':'Dist a arrivee km moy'})
classement_moyen_df.head()
| Position moy | Vitesse kmh moy | VMG moy | Dist au premier km moy | Dist a arrivee km moy | Foil | ||
|---|---|---|---|---|---|---|---|
| Skipper | Bateau | ||||||
| Alan Roura | LA FABRIQUE | 18.718519 | 22.508519 | 10.467407 | 1171.605111 | 40152.608000 | 1.0 |
| Alex Thomson | HUGO BOSS | 6.237037 | 25.002963 | 11.518667 | 420.342222 | 39401.339852 | 1.0 |
| Alexia Barrier | 4MYPLANET | 27.637037 | 17.275481 | 7.803556 | 2610.052741 | 41591.044296 | 0.0 |
| Ari Huusela | STARK | 27.488889 | 16.584593 | 7.401778 | 2646.843852 | 41627.847259 | 0.0 |
| Armel Tripon | L'OCCITANE EN PROVENCE | 24.274074 | 21.595185 | 8.886148 | 2314.200444 | 41295.194667 | 1.0 |
# Création d'un jeu de données de compétences moyennes des courreurs, avec toutes
# les informations techniques disponibles
res_moyens_tech = pd.merge(classement_moyen_df.reset_index(),
boat_info_clean,
right_on = 'Skipper name decoded',
left_on = 'Skipper',
how='left')
Corrélation VMG-position
En croisant les informations techniques des bateaux et celles du classement, on peut dégager des intuitions à propos des éléments favorables ou défavorables aux participants de la course.
Ci-dessous, on regarde la position moyenne de chaque participant sur la durée totale de la course (depuis le 1er jour jusqu'aujourd'hui), et on y associe la VMG moyenne sur la même période.
La VMG donne une indication entre la vitesse du bateau et l'angle du CAP. Un skipper qui voudra aller le plus rapidement d'un point A à un point B, devra trouver le meilleur compris entre l'angle de sa trajectoire pour rester au plus proche du vent, et sa vitesse en noeuds. Dans l'exemple ci-dessous, la VMG est maximisée pour une angle de 50°. Le bateau parcourt une distance plus grande, mais il a le compromis idéal entre distance et vitesse. Sa VMG est donc maximale (source https://www.orange-marine.com/content/474-bien-comprendre-le-principe-de-la-vmg-a-la-voile).

fig, ax = plt.subplots(1, 1, figsize=(15, 5))
plot1 = res_moyens_tech[['VMG moy', 'Position moy', 'Skipper']]\
.groupby('Skipper').mean(['VMG moy', 'Position moy']).sort_values('Position moy', ascending=True)
plot1.plot.bar(ax=ax, alpha=0.6)
ax.set(xlabel='Skippers')
ax.set_title('Analyse de la corrélation entre VMG moyenne et position moyenne dans le classement', fontweight='bold');
Graphiquement, on constate une forte corrélation entre valeur élevée de VMG et bonne position dans le classement. Les participants qui ont le plus occupé les 1ères positions du classement, sont les participants dont les bateaux permettent une VMG la plus élevée.
Nous pouvons dès lors nous demander ce qui permet d'avoir une valeur de VMG élevée.
Quels sont les éléments techniques du bateau qui permettent de tels scores ? Est-ce dû à l'architecte du bateau, à la présence de foil, à la taille des voiles, etc ?
Impact de la présence d'un foil
Note : Le champ 'Nombre de dérives', présent dans la table des classements, donne une indication quant à la présence d'un foil. Mais dans les données scrapées pour les informations techniques, nous avons également récupéré un champ 'Foil', mieux renseigné, qui indique la présence ou l'absense de foil par 1 ou 0. Après s'être assuré de la cohérence des 2 champs, on prend celui le mieux renseigné pour la suite.
fig, ax = plt.subplots(1, 1, figsize=(10, 5))
plot1 = res_moyens_tech[['VMG moy', 'Position moy', 'Foil']]\
.groupby('Foil').mean(['VMG moy', 'Position moy'])
plot1.plot.bar(ax=ax, alpha=0.5)
ax.set(xlabel='Présence de foil')
ax.set_title('VMG moyenne et Position moyenne en fonction de la présence d\'un foil', fontweight='bold');
Nous avions vu qu'une VMG élevée était corrélée à une meilleure position en moyenne dans le classement. Si on différencie désormais les participants dont le bateau est équipé d'un foil, de ceux dont le bateau ne l'est pas, on constate qu'il existe un avantage majeur à utiliser un foil. En effet, celui-ci permet d'augmenter en moyenne sa VMG, et on retrouve donc la corrélation entre VMG élevée et bon classement.
Finalement, effectuons une simple régression linéraire entre les valeurs de VMG en moyenne pour les participants, et leur position moyenne dans le classement.
Les résultats ci-dessous permettent de distinguer clairement la relation linéaire entre ces 2 variables. Plus la VMG est élevée, meilleure est la position dans le classement.
Les pentes des droites de régression donnent un résultat interressant, puisqu'on voit que, si les bateaux équipés de foil ont tendance à avoir une VMG plus élevée que les autres, ces derniers, lorsqu'ils atteignent des valeurs de VMG suffisamment grande, ont tendance à obtenir de meilleures positions.
reg_plot2 = res_moyens_tech[res_moyens_tech['Foil']==1][['VMG moy', 'Position moy', 'Foil']]
reg_plot1 = res_moyens_tech[res_moyens_tech['Foil']==0][['VMG moy', 'Position moy', 'Foil']]
fig, ax = plt.subplots(1, 1, figsize=(10, 7))
sns.set_theme(color_codes=True)
sns.regplot(x = "VMG moy",
y = "Position moy",
label='Avec foil',
data = reg_plot2)
sns.regplot(x = "VMG moy",
y = "Position moy",
label='Sans foil',
data = reg_plot1)
plt.legend()
ax.set_title('Régressions linéraires entre VMG moyenne et position moyenne, selon la présence de foil',
fontweight='bold');
ax.set_xticks(range(6, 15));
Impacts de l'année de création du bateau
Observons s'il existe un lien entre l'année de création du bateau et la position des skippers dans le classement. En effet, on pourrait supposer que les bateaux récents disposent des dernières technologies et donc seraient plus performants.
Pour cela, nous allons commencer par regarder le nombre de bateaux construits par année, ce qui nous donnera une information quant au caractère récent des voiliers de la course.
sub_df = res_moyens_tech.set_index('Date de lancement')
sub_df = sub_df.resample('1Y')['Bateau'].count().reset_index()
sub_df['Année lancement'] = sub_df['Date de lancement'].dt.year
del sub_df['Date de lancement']
fig, ax = plt.subplots(1, 1, figsize=(10, 5))
ax = sub_df.set_index('Année lancement').plot(kind='bar', ax=ax)
ax.set_xlabel('Année de lancement')
ax.set_ylabel('Nombre de bateaux')
ax.set_title('Date de lancement du chantier des bateaux de la course 2020', fontweight='bold');
sub_df = res_moyens_tech[['VMG moy', 'Date de lancement']].set_index('Date de lancement')
sub_df = sub_df.groupby([pd.Grouper(freq='y')]).mean().reset_index()
sub_df['Année de lancement'] = sub_df['Date de lancement'].dt.year
del sub_df['Date de lancement']
sub_df = sub_df.dropna().set_index('Année de lancement')
sub_df2 = res_moyens_tech[['Position moy', 'Date de lancement']].set_index('Date de lancement')
sub_df2 = sub_df2.groupby([pd.Grouper(freq='y')]).mean().reset_index()
sub_df2['Année de lancement'] = sub_df2['Date de lancement'].dt.year
del sub_df2['Date de lancement']
sub_df2 = sub_df2.dropna().set_index('Année de lancement')
fig, ax = plt.subplots(1, 2, figsize=(15, 7))
ax[0] = sub_df.plot.bar(ax=ax[0], alpha=0.6)
ax[0].set_ylabel('VMG moyenne')
ax[0].set_title('VMG moyenne en fonction de l\'année du bateau', fontweight='bold')
ax[1] = sub_df2.plot.bar(ax=ax[1], color='g', alpha=0.6)
ax[1].set_ylabel('Position moyenne dans le classement')
ax[1].set_title('Position moyenne en fonction de l\'année du bateau', fontweight='bold');
La faible volumétrie du nombre de bateaux par années nous empêche de tirer des conclusions pertinentes. En effet, il semble que les bateaux les plus récents (i.e date de lancement > 2007) permettent d'atteindre des VMG plus élevées en moyenne et donc de meilleures position dans le classement, mais nous avons également constaté que la plupart des bateaux de la course ont été construits après 2007.
Distances parcourues par les skippers
Au delà des trajectoires que nous avons pu voir sur les différentes cartes, il peut être intéressant de regarder l'évolution de la distance parcourue par participant et par date. De cette manière, nous pourrons distinguer si certains participants accélèrent et prennent de l'avance.
Ainsi on peut remarquer que le skipper Jérémie Beyou, qui avait du retourner au point de départ de la course (cf problème technique), parcourt une distance considérable en quelques jours, jusqu'à une position pas si éloignée de ses adversaires autour du 25 novembre.
min_date = classements_hist_clean['Date'].unique().min()
distance_totale = classements_hist_clean.loc[classements_hist_clean['Date']==min_date]\
['Dist a arrivee km'].unique()[0]
plot2 = classements_hist_clean[['Skipper', 'Dist a arrivee km', 'Date']]
plot2['Distance parcourue'] = plot2['Dist a arrivee km'].apply(lambda x: distance_totale - x)
fig = px.line(plot2, x='Date', y='Distance parcourue',
color='Skipper',
title='<b>Distance parcourue par les skippers (km)<b>')
fig.update_xaxes(rangeslider_visible=True)
fig.show()
Vitesse moyenne par jour
En plus de la distance parcourue, nous pouvons regarder en parallèle la vitesse des skippers, afin d'avoir une vision plus 'dynamique' de la course.
On peut voir qu'à partir 21 Novembre, la courbe de distance parcourue à tendance à s'applatir, et on constate que la vitesse moyenne quotidienne diminue fortement pour tous les skippers (jusque là, la vitesse moyenne avait été plutôt constante, voir en progression au fur et à mesure de l'avancement de la course).
vit_moy_day = classements_hist_clean[['Skipper', 'Date', 'Vitesse kmh']].set_index('Date')
vit_moy_day = vit_moy_day.groupby([pd.Grouper(freq="d"), "Skipper"]).mean().reset_index()
fig = tls.make_subplots(rows=2, cols=1)
fig1 = px.line(plot2, x='Date', y='Distance parcourue',
color='Skipper',
title='Distance parcourue par les skippers (km)')
for nb_skip in range(len(fig1['data'])):
fig.append_trace(fig1['data'][nb_skip], 1, 1)
fig2 = px.line(vit_moy_day, x='Date', y='Vitesse kmh',
color='Skipper')
for nb_skip in range(len(fig2['data'])):
fig.append_trace(fig2['data'][nb_skip], 2, 1)
fig.update_yaxes(title_text="Distance parcourue km", row=1, col=1)
fig.update_yaxes(title_text="Vitesse moyenne par jour kmh", row=2, col=1)
fig.update_layout(title_text="<b>Distance parcourue (km) et vitesse moyenne par jour (kmh)<b>", height=700)
iplot(fig);
J'essaye dans un premier de construire un modèle linéaire permettant, sur la base des données techniques des bateaux et sur les données de course depuis son départ, de prédire la position moyenne d'un skipper dans les 5 jours à venir. Aux données existantes, j'ajoute des données calculées afin de compléter les informations. Ces données sont les suivantes pour chaque skipper :
Ensuite, je découpe mon jeu de données de course, en prenant les informations depuis le début de la course, jusqu'à 5 jours avant la date actuelle. Puis j'essaye de prédire les positions des skippers sur ces 5 derniers jours de course, et je compare mes résultats avec les données effectivement récoltées ces 5 derniers jours.
Dans un second temps, j'utilise un modèle de séries temporelles 'ARIMA' afin d'obtenir une prédiction du classement des skippers 80 jours après le début de la course. La limite de 80 est prise arbitrairement, sur la base des temps moyens des recors des éditions précédents (entre 85 et 75 jours pour les meilleurs).
Ce modèle n'est qu'une ébauche et ne tient pas compte de nombreux paramètres pouvant influencer la course. Par exemple, l'information d'abandon de certains skippers n'est pas prise en compte à l'heure actuelle. Pour optimiser le système, il serait également intéressant d'étudier de plus près la modélisation de la VMG en fonction du temps.
Feature engineering
def data_moy_last3(df):
''' Données moyennes sur les 3 derniers jours '''
last_3_days = sorted(df['Date'].unique(), reverse=True)[:18]
X_train_last3 = df[df['Date'].isin(last_3_days)][['Skipper', 'VMG', 'Position',
'Vitesse nds', 'Dist au premier nm']]
X_train_last3 = X_train_last3.groupby(['Skipper']).mean(['VMG', 'Vitesse nds', 'VMG', 'Dist au premier nm',
'Position'])\
.rename(columns={'Vitesse nds':'Vitesse nds moy last3',
'VMG':'VMG moy last3',
'Dist au premier nm':'Dist au premier moy last3',
'Position':'Position moy last3'})\
.reset_index()
return X_train_last3
def data_moy(df):
''' Données moyennes sur toute la période considérée '''
df_res = df[['Skipper', 'VMG', 'Vitesse nds', 'Dist au premier nm', 'Position']]
df_res = df_res.groupby(['Skipper']).mean(['VMG', 'Vitesse nds', 'VMG', 'Dist au premier nm'])\
.rename(columns={'Vitesse nds':'Vitesse nds moy',
'VMG':'VMG moy',
'Dist au premier nm':'Dist au premier moy',
'Position':'Position moy'})\
.reset_index()
return df_res
def create_train_set(df, var_selection, nb_periodes):
''' Retourne les datasets (X et y) d'entrainement sur les variables selectionnées '''
test_date = sorted(df['Date'].unique(), reverse=True)[:nb_periodes]
#liste_skippers = df[df['Date'].isin(test_date)]['Skipper'].unique()
X_train = df[~df['Date'].isin(test_date)][var_selection]
X_train_last3 = data_moy_last3(X_train)
X_train = pd.merge(X_train,
data_moy(X_train),#().drop(['Foil', 'Bateau'], axis=1),
left_on = 'Skipper',
right_on = 'Skipper',
how='left')
X_train = pd.merge(X_train,
X_train_last3,
left_on = 'Skipper',
right_on = 'Skipper',
how='left')
X_train = X_train.loc[~X_train['Vitesse nds moy last3'].isna()]
skippers_list = X_train['Skipper'].unique()
y_train_temp = df[~df['Date'].isin(test_date)][['Date', 'Skipper', 'Position']]
y_train = y_train_temp[y_train_temp['Skipper'].isin(skippers_list)]
del X_train['Position']
return X_train, y_train
def create_test_set(df, var_selection, nb_periodes):
''' Retourne le dataset de test sur les variables selectionnées '''
test_date = sorted(df['Date'].unique(), reverse=True)[:nb_periodes]
#liste_skippers = df[df['Date'].isin(test_date)]['Skipper'].unique()
X_test = df[df['Date'].isin(test_date)][var_selection]
X_test_last3 = data_moy_last3(X_test)
X_test = pd.merge(X_test,
data_moy(X_test),
left_on = 'Skipper',
right_on = 'Skipper',
how='left')
X_test = pd.merge(X_test,
X_test_last3,
left_on = 'Skipper',
right_on = 'Skipper',
how='left')
X_test = X_test.loc[~X_test['Vitesse nds moy last3'].isna()]
skippers_list = X_test['Skipper'].unique()
y_test_temp = df[df['Date'].isin(test_date)][['Date', 'Skipper', 'Position']]
y_test = y_test_temp[y_test_temp['Skipper'].isin(skippers_list)]
del X_test['Position']
return X_test, y_test
Model
def model_training(X, y):
''' Linear regression model '''
clf = LinearRegression(fit_intercept = True)
clf_fitted = clf.fit(X.iloc[:, 2:], y.iloc[:, 2:])
return clf_fitted
# Construction du modèle de machine learning
dataset_total = pd.merge(classements_hist_clean.reset_index(),
boat_info_clean,
right_on = 'Skipper name decoded',
left_on = 'Skipper',
how='left')
var_selection = ['Date', 'Skipper', 'Foil', 'Vitesse nds', 'VMG', 'Dist au premier nm', 'Dist a arrivee nm',\
'Latitude', 'Longitude', 'Déplacement (poids)', 'Hauteur mât', \
'Largeur', 'Surface de voiles au portant', 'Surface de voiles au près', 'Position']
X_train, y_train = create_train_set(dataset_total, var_selection, 30)
X_test, y_test = create_test_set(dataset_total, var_selection, 30)
model = model_training(X_train, y_train)
Prédictions
predictions = (model.predict(X_test.iloc[:, 2:]))
# Conversion des predictions
total_df = pd.DataFrame()
y_test['Predictions_raw'] = predictions
for date in y_test['Date']:
df_temp = y_test[y_test['Date']==date]
df_temp["Predictions"] = df_temp["Predictions_raw"].rank()
total_df = pd.concat([total_df, df_temp], axis=0)
Graphiques
skippers = total_df['Skipper'].unique()[:5]
tot = len(skippers)
fig, ax = plt.subplots(1, 5, figsize=(16, 3))
for idx, skipper in enumerate(skippers):
sub = total_df[total_df['Skipper']==skipper]
p1 = ax[idx].plot(sub['Date'], sub['Position'], label='Position', c='b')
p2 = ax[idx].plot(sub['Date'], sub['Predictions'], label='Prédictions', c='r')
ax[idx].legend([p1, p2],
['Position', 'Prédiction'],
loc='upper right')
ax[idx].set_title(skipper, fontweight='bold')
ax[idx].set_xticklabels(sub['Date'].sort_values().dt.day.unique(), rotation=45)
plt.legend()
plt.show()
On voit que si la prédiction ne s'écart pas énormément des posistions réelles constatées pour les skippers, il existe néanmoins un "bruit" permanent.
En l'état, cette prédiction n'a pas énormément de valeur, puisque les données de course ne sont pas utilisées pour prédire une position lointaine dans le futur.
Le modèle de série temporelle attribue un score à chacun des skippers. Ensuite ce score est convertit en rang, afin d'obtenir une vision à 80 jours (après le début de la course) du classement des skippers.
Model
res = {}
for skip in dataset_total['Skipper'].unique():
st = dataset_total[dataset_total['Skipper']==skip][['Date', 'Position']].set_index('Date')
for q in range(5):
try:
model = ARIMA(st, order=(1, 1, q))
fitted_model = model.fit()
res[skip] = fitted_model.predict(1, 450)[0] # Prédiction à 80 jours après le départ
except:
pass
Prédiction du classement à 80 jours après le départ
dict_ranks = {key: rank for rank, key in enumerate(sorted(res, key=res.get, reverse=True), 1)}
predictions_df = pd.DataFrame.from_dict(dict_ranks, orient ='index')
max_date = dataset_total['Date'].unique().max()
sub = dataset_total[dataset_total['Date'] == max_date][['Position', 'Skipper']]
predictions_df = pd.merge(predictions_df.reset_index(), sub,
left_on='index', right_on='Skipper')
del predictions_df['Skipper']
predictions_df = predictions_df.set_index('index')
predictions_df.columns=['Position prédite', 'Position actuelle']
predictions_df
| Position prédite | Position actuelle | |
|---|---|---|
| index | ||
| Jean Le Cam | 1 | 4 |
| Giancarlo Pedote | 2 | 10 |
| Romain Attanasio | 3 | 16 |
| Fabrice Amedeo | 4 | 25 |
| Yannick Bestaven | 5 | 6 |
| Maxime Sorel | 6 | 14 |
| Kevin Escoffier | 7 | 3 |
| Thomas Ruyant | 8 | 2 |
| Benjamin Dutreux | 9 | 11 |
| Sebastien Simon | 10 | 7 |
| Boris Herrmann | 11 | 8 |
| Stephane Le Diraison | 12 | 19 |
| Isabelle Joschke | 13 | 13 |
| Alexia Barrier | 14 | 27 |
| Samantha Davies | 15 | 12 |
| Alan Roura | 16 | 18 |
| Didac Costa | 17 | 23 |
| Arnaud Boissieres | 18 | 20 |
| Charlie Dalin | 19 | 1 |
| Louis Burton | 20 | 5 |
| Clarisse Cremer | 21 | 17 |
| Ari Huusela | 22 | 29 |
| Damien Seguin | 24 | 9 |
| Manuel Cousin | 25 | 22 |
| Alex Thomson | 26 | 15 |
| Miranda Merron | 27 | 28 |
| Armel Tripon | 28 | 21 |
| Pip Hare | 29 | 24 |
| Kojiro Shiraishi | 30 | 30 |
| Jeremie Beyou | 31 | 32 |
| Sebastien Destremau | 32 | 31 |
| Clement Giraud | 33 | 26 |
Carte interactive de la course
Contenu du fichier _plotrace.py
import plotly.graph_objects as go
import pandas as pd
from plotly.offline import plot
test = pd.read_csv('data/plot.csv')
skippers = test['Skipper'].unique()
skipper1 = skippers[0]
temp = test[test['Skipper']==skipper1]
nl = '\n'
bateau = temp['Bateau'].unique()[0]
vitesse_kmh = temp['Vitesse kmh'].unique()[0]
position = temp['Position'].unique()[0]
foil = temp['Foil'].unique()[0]
nb_skip = test['Skipper'].nunique()
fig = go.Figure(go.Scattermapbox(
mode = "markers+lines",
lon = temp['Longitude'].values,
lat = temp['Latitude'].values,
name = skipper1,
hovertemplate = f'<b>Bateau</b>: {bateau}<br>' +
f'<b>Skipper</b>: {skipper1}<br>' +
f'<b>Position</b>: {position}<br>' +
f'<b>Vitesse kmh</b>: {vitesse_kmh}<br>' +
f'<b>Foil</b>: {foil}<br>',
marker = {'size': 2}))
for skip in skippers[1:len(skippers)]:
temp = test[test['Skipper']==skip]
nl = '\n'
bateau = temp['Bateau'].unique()[0]
vitesse_kmh = temp['Vitesse kmh'].unique()[0]
position = temp['Position'].unique()[0]
foil = temp['Foil'].unique()[0]
fig.add_trace(go.Scattermapbox(
mode = "markers+lines",
lon = temp['Longitude'].values,
lat = temp['Latitude'].values,
hovertemplate = f'<b>Bateau</b>: {bateau}<br>' +
f'<b>Skipper</b>: {skip}<br>' +
f'<b>Position</b>: {position}<br>' +
f'<b>Vitesse kmh</b>: {vitesse_kmh}<br>' +
f'<b>Foil</b>: {foil}<br>',
name = skip,
marker = {'size': 2}))
fig.update_layout(
mapbox_style="white-bg",
mapbox_layers=[
{
"below": 'traces',
"sourcetype": "raster",
"sourceattribution": "United States Geological Survey",
"source": ["https://basemap.nationalmap.gov/arcgis/rest/services/USGSImageryOnly/MapServer/tile/{z}/{y}/{x}"]
}
])
fig.update_layout(
autosize = True,
margin ={'l':0,'t':0,'b':0,'r':0},
mapbox = {
'center': {'lon': 10, 'lat': 10},
'style': "stamen-terrain",
'center': {'lon': -20, 'lat': -20},
'zoom': 1})
plot(fig, validate = False, filename='plot_race.html',
auto_open=True)
#fig.show()
'plot_race.html'
Trajectoire de Jérémie Beyou
Suite à un problème technique après quelques jours de navigation, Jérémie Beyou retourne au point de départ, avant de repartir de plus belle dans la course.

Projection sur carte orthographique
import matplotlib.pyplot as plt
import cartopy.crs as ccrs
import cartopy
import numpy as np
import pandas as pd
from cartopy.mpl.gridliner import LONGITUDE_FORMATTER, LATITUDE_FORMATTER
import matplotlib.patches as mpatches
import cartopy.feature as cfeature
# -------------- Initialisation de la figure ---------------- #
fig = plt.figure(figsize=(10, 8))
# Frame global
left = -0.05
bottom = -0.05
width = 1.1
height = 1.05
rect = [left,bottom,width,height]
ax3 = plt.axes(rect)
# Frame map
left = 0.2
bottom = 0
width = 0.8
height = 0.90
rect = [left,bottom,width,height]
ax = plt.axes(rect, projection=ccrs.Orthographic(central_longitude=-5, central_latitude=-5))
ax.stock_img()
gl = ax.gridlines(crs=ccrs.PlateCarree(), draw_labels=True,
linewidth=2, color='gray', alpha=0.5, linestyle='--')
gl.yformatter = LATITUDE_FORMATTER
# -------------- Import des données ---------------- #
classements_hist_clean = pd.read_pickle("dataframes/classements_hist_clean.pkl")
# -------------- Légende ---------------- #
left = 0
bottom = 0.4
width = 0.16
height = 0.5
rect = [left,bottom,width,height]
rect = [left,bottom,width,height]
#ax5 = plt.axes(rect)
colors = sorted(cartopy.feature.COLORS.keys())
handles = []
names = []
for c in colors:
patch = mpatches.Patch(color=cfeature.COLORS[c], label=c)
handles.append(patch)
names.append(c)
# -------------- Ajout des données de la course ---------------- #
skippers = classements_hist_clean['Skipper'].values
nb_skip = classements_hist_clean['Skipper'].nunique()
sub_skippers_liste = list(skippers)[0:nb_skip-1]
sub_skippers_select = sub_skippers_liste[:3] + sub_skippers_liste[-3:]
for skipper in sub_skippers_select:
classement_skip = classements_hist_clean[classements_hist_clean['Skipper']==skipper]
lon = classement_skip['Longitude']
lat = classement_skip['Latitude']
bateau = classement_skip['Bateau'].unique()[0]
position = classement_skip['Position'].unique()[0]
plot_skip = ax.plot(lon, lat,
linestyle='-',
transform=ccrs.Geodetic(),
label = f'{skipper} | {bateau} #{position}'
)
handles.append(plot_skip)
names.append(skipper)
ax.set_global()
ax.coastlines()
ax.gridlines()
ax.legend(loc='upper right', prop={'size': 6})
fig.savefig('img/plot_orthographic_30112020.png')
plt.show()
Projection sur carte depuis le pôle Sud
import matplotlib.pyplot as plt
import cartopy.crs as ccrs
import cartopy
import numpy as np
import pandas as pd
from cartopy.mpl.gridliner import LONGITUDE_FORMATTER, LATITUDE_FORMATTER
import matplotlib.patches as mpatches
import cartopy.feature as cfeature
# -------------- Initialisation de la figure ---------------- #
fig = plt.figure(figsize=(10, 8))
# Frame global
left = -0.05
bottom = -0.05
width = 1.1
height = 1.05
rect = [left,bottom,width,height]
ax3 = plt.axes(rect)
# Frame map
left = 0
bottom = 0
width = 0.95
height = 0.95
rect = [left,bottom,width,height]
#ax = fig.add_subplot(1, 1, 1, projection=ccrs.Orthographic(central_longitude=-5, central_latitude=-5))
ax = plt.axes(rect, projection=ccrs.SouthPolarStereo())
ax.stock_img()
gl = ax.gridlines(crs=ccrs.PlateCarree(), draw_labels=True,
linewidth=2, color='gray', alpha=0.5, linestyle='--')
gl.yformatter = LATITUDE_FORMATTER
# -------------- Import des données ---------------- #
classements_hist_clean = pd.read_pickle("dataframes/classements_hist_clean.pkl")
# -------------- Légende ---------------- #
left = 0
bottom = 0.4
width = 0.16
height = 0.5
rect = [left,bottom,width,height]
rect = [left,bottom,width,height]
#ax5 = plt.axes(rect)
colors = sorted(cartopy.feature.COLORS.keys())
handles = []
names = []
for c in colors:
patch = mpatches.Patch(color=cfeature.COLORS[c], label=c)
handles.append(patch)
names.append(c)
# -------------- Ajout des données de la course ---------------- #
skippers = classements_hist_clean['Skipper'].values
nb_skip = classements_hist_clean['Skipper'].nunique()
sub_skippers_liste = list(skippers)[0:nb_skip-1]
sub_skippers_select = sub_skippers_liste[:3] + sub_skippers_liste[-3:]
for skipper in sub_skippers_select:
classement_skip = classements_hist_clean[classements_hist_clean['Skipper']==skipper]
lon = classement_skip['Longitude']
lat = classement_skip['Latitude']
bateau = classement_skip['Bateau'].unique()[0]
position = classement_skip['Position'].unique()[0]
plot_skip = ax.plot(lon, lat,
linestyle='-',
transform=ccrs.Geodetic(),
label = f'{skipper} #{position}'
)
handles.append(plot_skip)
names.append(skipper)
#ax.set_global()
ax.coastlines()
ax.gridlines()
ax.legend(loc='upper left', prop={'size': 6})
ax.set_extent([-50, 10, -50, 50])
fig.savefig('img/plot_southPole_30112020.png')
#plt.show();